- Görkem Güray/
- SwiftUI in 100 Days Notes/
- Day 58 - SwiftUI SwiftData: Query Sorting and Filtering, Relationship and CloudKit Synchronization/
Day 58 - SwiftUI SwiftData: Query Sorting and Filtering, Relationship and CloudKit Synchronization
Table of Contents
Today we will work on topics such as NSPredicate
, dynamically changing fetch requtes, creating relationships. The topics we will work on today;
- Dynamically sorting and filtering
@Query
with SwiftUI - Relationship with SwiftData, SwiftUI and
@Query
- Synchronize SwiftData with CloudKit
Dynamically Sort and Filter @Query with SwiftUI #
Now that you’ve seen a bit of how SwiftData’s #Predicate
works, your next question is probably “how can I make it work with user input?”. The answer is … complicated. I’ll show you how it’s done, and how the same technique can be used to dynamically adjust the ordering, but it will take you a while to remember how.
Based on the SwiftData code we looked at earlier, each user object had a different joinDate
property, some in the past and some in the future. We also had a List
that showed the results of a query:
List(users) { user in
Text(user.name)
}
What we are going to do is to move this list to a separate view - specifically a view to run the SwiftData query and show its results, then optionally have it show all users or only users who will join in the future.
So, create a new SwiftUI view called UsersView
, add a SwiftData import to it, then move the List
code there without including any of its modifiers - just the code shown above.
Now that we have the SwiftData results displayed in the UsersView
, we need to add a @Query
property there. This should not use a sort order or predicate - at least not yet. So, add this property there;
@Query var users: [User]
And add modelContainer()
modifier to preivew. Finally you should get a code like the one below.
import SwiftData
import SwiftUI
struct UsersView: View {
@Query var users: [User]
var body: some View {
List(users) { user in
Text(user.name)
}
}
}
#Preview {
UsersView()
.modelContainer(for: User.self)
}
Before we are done with this view, we need a way to customize the query being executed. As it stands, just using @Query var users: [User]
means that SwiftData will load all users without filter and sort order, but we really want to customize one or both of them from the ContentView
.
This is best done by passing a value to the view using an initializer and then building the query using that value. As I mentioned earlier, our goal is to either show all users or only users who will join in the future. We will accomplish this by passing a minimum join date and making sure that all users have joined at least after that date.
Add this initializer to UserView
now;
init(minimumJoinDate: Date) {
_users = Query(filter: #Predicate<User> { user in
user.joinDate >= minimumJoinDate
}, sort: \User.name)
}
This is mostly the code you are used to, but notice that there is an underscore before users
. This is intentional: we are not trying to modify the User
array, we are trying to modify the SwiftData query that generates the array. The underscore is the way Swift accesses this query, i.e. we create the query from the date passed in.
At this point, we are done with UsersView
, so now we need to go back to ContentView
and delete the existing @Query
property and replace it with code that modifies some kind of Boolean value and pass the current state of that value to UsersView
.
First, add this new @State
property to ContentView
:
@State private var showingUpcomingOnly = false
And now replace the List
code in ContentView
- again, without including modifiers - with this:
UsersView(minimumJoinDate: showingUpcomingOnly ? .now : .distantPast)
This passes one of two dates to the UsersView
: When our Boolean property is true, we pass .now
to show only users who will join after the current time, otherwise we pass .distantPast
, which is at least 2000 years ago. Unless there are some Roman emperors among our users, all users will be shown, as their join dates will be long after that.
Add this to the ContentView
toolbar:
Button(showingUpcomingOnly ? "Show Everyone" : "Show Upcoming") {
showingUpcomingOnly.toggle()
}
This changes the tag of the button so that it always reflects what happens the next time it is pressed.
This completes all the work, so if you run the application now, you will see that you can dynamically change the list of users.
Yes, it’s quite a lot of work, but as you can see it works perfectly and you can apply the same technique to other types of filtering.
This approach works equally well for sorting data: We can check an array of sort descriptors in ContentView
, then pass them to the initializer of UsersView
to set up the query.
First, we need to upgrade the UsersView
initializer to accept some kind of sort descriptor for our User
class. This again uses Swift’s generics: SortDescriptor
type needs to know what it is sorting, so we need to specify User
in square brackets.
Change the UsersView
initializer to this:
init(minimumJoinDate: Date, sortOrder: [SortDescriptor<User>]) {
_users = Query(filter: #Predicate<User> { user in
user.joinDate >= minimumJoinDate
}, sort: sortOrder)
}
You will also need to update your preview code to pass a sample sort order so that your code compiles properly:
UsersView(minimumJoinDate: .now, sortOrder: [SortDescriptor(\User.name)])
.modelContainer(for: User.self)
Go back to ContentView
and add another new property to keep the current sort order. We’ll do it in a way that uses name first then join date, this seems like a logical default:
@State private var sortOrder = [
SortDescriptor(\User.name),
SortDescriptor(\User.joinDate),
]
Then we can pass it to the UsersView
as we did with the join date:
UsersView(minimumJoinDate: showingUpcomingOnly ? .now : .distantPast, sortOrder: sortOrder)
And finally we need a way to dynamically adjust this array. One option is to use a Picker
that shows two options: Sort by Name and Sort by Join Date. This in itself is not difficult, but how do we add a SortDescriptor
array to each option?
The answer lies in a handy modifier called tag()
. This allows us to add specific values of our own choosing to each picker option. Here this means that we can make each option’s tag its own SortDescriptor
array and SwiftUI will automatically assign this tag to the sortOrder
property.
Try adding this to the toolbar:
Picker("Sort", selection: $sortOrder) {
Text("Sort by Name")
.tag([
SortDescriptor(\User.name),
SortDescriptor(\User.joinDate),
])
Text("Sort by Join Date")
.tag([
SortDescriptor(\User.joinDate),
SortDescriptor(\User.name)
])
}
Now when you run the app, you probably won’t see what you expect. Depending on the device you are using, instead of showing “Sort” as a menu with options, you will see one of the following:
- Three dots in a circle and when pressed, the options appear.
- “Sort by Name” is shown directly in the navigation bar, and tapping it will let you jump to the Join Date.
Neither option is great, but I would like to take this opportunity to introduce another useful SwiftUI view, Menu
. This allows you to create menus in the navigation bar and you can place buttons, selectors and more inside it.
In this case, if we wrap our existing Picker
code with a Menu
, we will get a much better result. Try this:
Menu("Sort", systemImage: "arrow.up.arrow.down") {
// current picker code
}
Try it again and you will see that it is much better. More importantly, both our dynamic filtering and sorting now work great!
SwiftData, SwiftUI and relationship with @Query #
SwiftData allows us to create models that reference each other. For example, we can say that a School
model has an array of many Student
objects, or that an Employee
model stores a Manager
object.
These are called relationships and they come in various forms. SwiftData does a good job of automatically creating these relationships as long as you tell it what you want, but there is still room for some surprises!
Let’s try them out now. We already have the following User
model:
@Model
class User {
var name: String
var city: String
var joinDate: Date
init(name: String, city: String, joinDate: Date) {
self.name = name
self.city = city
self.joinDate = joinDate
}
}
We can extend this to say that each User
can have a job array attached to it - tasks that they need to complete as part of their job. To do this, we first need to create a new Job
model like this:
@Model
class Job {
var name: String
var priority: Int
var owner: User?
init(name: String, priority: Int, owner: User? = nil) {
self.name = name
self.priority = priority
self.owner = owner
}
}
Notice how the owner
property directly references the User
model - I explicitly told SwiftData that the two models are connected.
And now we can set the User
model to create a job array:
var jobs = [Job]()
So, jobs have an owner and users have a job array - the relationship is bidirectional, which is usually a good idea because it makes it easier to work with your data.
This array will start working immediately: SwiftData will load all of a user’s jobs the first time they are requested, so if they are never used, it will skip that job.
Even better, the next time our application is launched, SwiftData will silently add the jobs
property to all its existing users, giving them an empty array by default. This is called a migration: when we add or delete features in our models as our needs evolve over time. SwiftData can do simple migrations like this automatically, but as you progress you will learn how you can create custom migrations to handle larger model changes.
**When we use the modelContainer()
modifier in our App
struct, we pass User.self
so that SwiftData knows that it needs to set storage for this model. We don’t need to add Job.self
there because SwiftData can see that there is a relationship between the two, so it takes care of both automatically.
You don’t need to change the @Query
you use to load your data, just keep using the array as normal. For example, we can show a list of users and the number of jobs as follows:
List(users) { user in
HStack {
Text(user.name)
Spacer()
Text(String(user.jobs.count))
.fontWeight(.black)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(.blue)
.foregroundStyle(.white)
.clipShape(.capsule)
}
}
If you want to see it working with some real data, you can create a SwiftUI view that will create new Job
instances for the selected user, but for testing purposes we can take a small shortcut and add some sample data.
First, add a property to access the active SwiftData model context:
@Environment(\.modelContext) var modelContext
And now add a method like this to generate some sample data:
func addSample() {
let user1 = User(name: "Piper Chapman", city: "New York", joinDate: .now)
let job1 = Job(name: "Organize sock drawer", priority: 3)
let job2 = Job(name: "Make plans with Alex", priority: 4)
modelContext.insert(user1)
user1.jobs.append(job1)
user1.jobs.append(job2)
}
Again, notice that almost all of this code is just regular Swift - only one line is really SwiftData related.
To see this immediately, add the following modifier to List;
.onAppear(perform: addSample)
Your starting point should always be to assume that working with your data is just like working with a normal @Observable
class - let SwiftData do its thing until you have a reason to do otherwise!
There is one small problem though, and it’s worth addressing it before moving on: We’ve linked User
and Job
in such a way that a user can have many jobs to do, so what happens if we delete a user?
The answer is that all their jobs remain intact - they are not deleted. This is a smart move from SwiftData, because there is no surprise data loss.
If you specifically want all of a user’s job objects to be deleted at the same time, we need to tell SwiftData that. This is done using a @Relationship
macro and providing a deletion rule that describes how Job
objects should be handled when the owner User
is deleted.
The default deletion rule is called .nullify
, which means that the owner
property of each Job
object is set to nil, indicating that it has no owner. We will change this to .cascade
, meaning that deleting one User
should automatically delete all Job
objects. This is called a cascade because the deletion continues for all related objects - for example, if our Job
object had a locations
relation, these would also be deleted, and so on.
So, change the jobs
property in User
like this:
@Relationship(deleteRule: .cascade) var jobs = [Job]()
And we are now open, meaning we don’t leave any hidden Job
objects when deleting a user - much better!
Synchronizing SwiftData with CloudKit #
SwiftData can synchronize all your user data with iCloud, and the best part is that this usually requires writing no code at all.
Before we start, there’s an important caveat: Synchronizing data to iCloud requires an active Apple developer account. If you don’t have an account, the following will not work.
You’re still here, right? Ok, to sync data from local SwiftData storage to iCloud, you need to enable iCloud in your app. We haven’t customized app capabilities before, so this step is new.
First, click on the “SwiftDataTest” app icon at the top of your project explorer. This should be just above the SwiftDataTest group.
Second, select “SwiftDataTest” under the “TARGETS” list. You should see a bunch of tabs: General, Signing & Capabilities, Resource Tags, Info and more. We want Signing & Capabilities, so now please select it.
Third, press the “+ CAPABILITY” button and select iCloud, this will make iCloud appear in the list of active capabilities - you will see three services possible, a “CloudKit Console” button and more.
Fourth, check the box with CloudKit checked, which will allow our app to store SwiftData information in iCloud. You will also need to press the + button to add a new CloudKit container that configures where the data is actually stored in iCloud. Here you should use your app’s package ID prefix with “iCloud.”, for example iCloud.com.hackingwithswift.swiftdatatest.
Fifth, press the “+ CAPABILITY” button again and add the Background Modes capability. This has a bunch of configuration options, but you just need to check the “Remote Notifications” box - this will let the app know when data changes in iCloud so it can be synchronized locally.
And that’s it - your app is ready to use iCloud to synchronize SwiftData.
Maybe.
You see, SwiftData with iCloud has a requirement that native SwiftData does not require: all properties must be optional or have default values and all relations must be optional. The first requirement is a minor annoyance, but the second one is a much bigger annoyance - it can be quite disruptive for your code.
But these are requirements, not suggestions. Therefore, in the Job
example we will need to set the properties in this way:
var name: String = "None"
var priority: Int = 1
var owner: User?
Ve User
için, bunu kullanmamız gerekecek:
var name: String = "Anonymous"
var city: String = "Unknown"
var joinDate: Date = Date.now
@Relationship(deleteRule: .cascade) var jobs: [Job]? = [Job]()
Important: If you don’t make these changes, iCloud simply won’t work. If you look at Xcode’s logs - and CloudKit loves to write to Xcode’s logs - when you scroll near the top, SwiftData should try to warn you when any feature is preventing iCloud synchronization from working correctly.
Once you’ve set up your models, your code needs to be modified to handle optional’s correctly. For example, when adding a job to a user, optional chaning can be used like this:
user1.jobs?.append(job1)
user1.jobs?.append(job2)
And reading the number of jobs of a user can be done like this, using optional chaining and nil coalescing:
Text(String(user.jobs?.count ?? 0))
I’m not a big fan of spreading this kind of code all over the project, so if I use jobs regularly, I prefer to create a read-only computed property called unwrappedJobs
or something similar - this property returns jobs
if it has a value, otherwise it returns an empty array, like this:
var unwrappedJobs: [Job] {
jobs ?? []
}
It’s a small detail, but it helps to make the rest of the code smoother, and making it read-only prevents you from accidentally trying to replace a missing array.
Important: The simulator is built to test native SwiftData apps, but it’s quite inadequate at testing iCloud - you may find that your data doesn’t sync correctly, quickly or at all. Please use a real device to avoid problems!
You can also read this article in Turkish.
Bu yazıyı Türkçe olarak da okuyabilirsiniz.